<?php

/**
 * @file GNU Gettext Portable Object Import API.
 */

// == Batch operations and finish callbacks for .po file imports ===============

/**
 * Batch callback to continue importing a .po file.
 */
function gettextapi_import_batch_op($file, $langcode, $mode, $textgroup, &$context) {
  if (empty($context['sandbox'])) {
    // Initialize sandbox with file URI, size and chunk size for batch reading.
    $context['sandbox']['parse_state'] = array(
      'file' => $file->uri,
      'filesize' => filesize($file->uri),
      'chunk_size' => 1000,
    );
  }

  // Pass on environment specific write options.
  $write_options = array(
    'langcode' => $langcode,
    'mode' => $mode,
    'textgroup' => $textgroup,
  );

  // Execute one reading operation, which will process a bit more than 1000
  // lines at once with the above configuration.
  $success = gettextapi_import_read_po(
    'gettextapi_import_read_callback', $context['sandbox']['parse_state'],
    'gettextapi_import_write_callback', $write_options,
    'gettextapi_import_error_callback'
  );

  if (!$context['sandbox']['parse_state']['finished']) {
    // Not yet finished with reading, mark progress based on size and position.
    // Maximize the progress bar at 95% before completion because batch API
    // could trigger end of operation before file reading is done for floating
    // point inaccuracies. See http://drupal.org/node/1089472
    $context['finished'] = min(0.95, ($context['sandbox']['parse_state']['fseek'] / $context['sandbox']['parse_state']['filesize']));
  }
  else {
    // Finished in this case. Mark this for the batch to go to the next operation.
    $context['finished'] = 1;

    if ($success) {
      // If the file reading ended successfully, inform the user here. We do
      // not let this to the batch end function because we might handle any
      // number of .po files in one batch set, and therefore we should do
      // messaging right when we end each file.
      $stats = $context['sandbox']['parse_state']['write_stats'];
      drupal_set_message(t('%file successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%file' => $file->uri, '%number' => $stats['added'], '%update' => $stats['updated'], '%delete' => $stats['deleted'])));
      watchdog('locale', 'Imported %file: %number new strings added, %update updated and %delete removed.', array('%file' => $file->uri, '%number' => $stats['added'], '%update' => $stats['updated'], '%delete' => $stats['deleted']));
    }

    if (empty($context['sandbox']['parse_state']['header'])) {
      // Inform the user about the missing header.
      drupal_set_message(t('%file header missing.', array('%file' => $file->uri)), 'warning');
    }

    // Remove the temporary file we've only needed for the import.
    // Does not fly well with l10n_update and local translations/*.po files. Well.
    // file_delete($results['file']);
  }

  // No need to do anything special if the file was not parsed successfully, the
  // error message was already communicated to the user, we don't need yet
  // another one.

  // The finish callback only needs the affected languages for cache clearing.
  $context['results']['languages_affected'][$write_options['langcode']] = TRUE;

  // Inform the user about our progress in the file textually.
  $context['message'] = t('Processing %file from line @line.', array('%file' => $file->uri, '@line' => $context['sandbox']['parse_state']['lineno']));
}

/**
 * Batch finish callback. Inform the user about the state of the import.
 */
function gettextapi_import_batch_finished($success, $results, $operations) {

  if ($success) {
    // Clear cache and force refresh of JavaScript translations.
    foreach ($results['languages_affected'] as $langcode => $true) {
      _locale_invalidate_js($langcode);
    }
    // Clear all locale translation caches.
    cache_clear_all('locale:', 'cache', TRUE);
    // Rebuild the menu, strings may have changed.
    menu_rebuild();
  }

  else {
    // Pick the operation with the error from the list of remaining operations.
    // Report the error to the user and the system log.
    $error_operation = reset($operations);
    $operation = array_shift($error_operation);
    $arguments = array_shift($error_operation);
    $arguments_as_string = implode(', ', $arguments);
    watchdog('gettextapi', "Error when running operation %operation(%arguments)", array('%operation' => $operation, '%arguments' => $arguments_as_string));
    drupal_set_message(t('An error occured while importing translations. It was recorded in the system log.'), 'error');
  }
}

// == Default read, write and error callbacks reproducing core behavior ========

/**
 * Default read callback that reads in the .po file all at once.
 */
function gettextapi_import_read_callback(&$parse_state, $error_callback) {

  if (empty($parse_state['handle'])) {
    // File not yet open. Open for reading.
    $parse_state['handle'] = fopen($parse_state['file'], 'rb');
    if (empty($parse_state['handle'])) {
      // Record error and mark parsing finished.
      $error_callback('file could not be read', $parse_state);
      $parse_state['finished'] = TRUE;
      return FALSE;
    }
    else {
      // File was just opened. Start chunk line counting. Initialize other
      // values if we did not get those initialized from the outside.
      // chunk_size can be set to use chunked reading, in which case these
      // state values will be remembered in batch information. We only
      // initialize the values if not yet provided.
      $parse_state['chunk_lineno'] = 0;
      $parse_state += array('lineno' => 0, 'last_write_lineno' => -1, 'chunk_size' => 0, 'finished' => FALSE);
      if (!empty($parse_state['fseek'])) {
        // Seek into the file where we left off reading.
        fseek($parse_state['handle'], $parse_state['fseek']);
      }
    }
  }

  if (!feof($parse_state['handle'])) {
    // The file is already open for reading either from above or from previously.

    if (!empty($parse_state['chunk_size']) &&
        ($parse_state['last_write_lineno'] == $parse_state['lineno']) &&
        ($parse_state['chunk_lineno'] > $parse_state['chunk_size'])) {
      // If we are doing chunk reads and the previously read line resulted in
      // cached data to be written out, we can store the position at the start
      // of the previous line, close the file and continue reading later.
      fclose($parse_state['handle']);
      unset($parse_state['handle']);
      // Set the context to COMMENT (which is valid inbetween saved strings
      // although not always true due to condensed file structures). This will
      // let parsing to finish peacefully.
      $parse_state['context'] = 'COMMENT';
      // Pretend the file ended.
      return FALSE;
    }

    // Remember current position in case we need it for chunked reading.
    $parse_state['fseek'] = ftell($parse_state['handle']);

    // A line should not be longer than 10 * 1024.
    $line = fgets($parse_state['handle'], 10 * 1024);

    if ($parse_state['lineno'] == 0) {
      // The first line might come with a UTF-8 BOM, which should be removed.
      $line = str_replace("\xEF\xBB\xBF", '', $line);
    }

    // Update line number and chunk line number counting.
    $parse_state['lineno']++;
    $parse_state['chunk_lineno']++;

    return $line;
  }
  else {
    // File reached its end. Mark read state finished.
    $parse_state['finished'] = TRUE;
    return FALSE;
  }
}

/**
 * Default write callback for gettextapi.
 */
function gettextapi_import_write_callback($string, &$parse_state, $write_options, $error_callback) {
  // Remember last write line number. Required for chunk reading support.
  $parse_state['last_write_lineno'] = $parse_state['lineno'];

  // Initialize default textgroup and database storage.
  $write_options += array('textgroup' => 'default', 'storage' => 'db');

  // Initialize stats we collect while storing strings.
  if (!isset($parse_state['write_stats'])) {
    $parse_state['write_stats'] = array(
      'added'   => 0,
      'updated' => 0,
      'deleted' => 0,
      'skipped' => 0,
      'header'  => FALSE,
      'strings' => array(),
    );
  }

  if ($write_options['storage'] == 'memory') {
    $parse_state['write_state']['strings'][isset($string['msgctxt']) ? $string['msgctxt'] : ''][$string['msgid']] = $string['msgstr'];
  }
  else {

    if ($string['msgid'] == '') {
      // Gettext PO file headers are keyes with the empty source string.
      $languages = language_list();
      if (($write_options['mode'] != LOCALE_IMPORT_KEEP) || empty($languages[$write_options['langcode']]->plurals)) {
        // Since we only need to parse the header if we ought to update the
        // plural formula, only run this if we don't need to keep existing
        // data untouched or if we don't have an existing plural formula.
        $header = gettextapi_import_parse_header($string['msgstr']);

        // Get the plural formula and update in the database if successful.
        if (!empty($header["Plural-Forms"]) && $p = gettextapi_import_parse_plural_forms($header["Plural-Forms"], $parse_state, $error_callback)) {
          list($nplurals, $plural) = $p;
          db_update('languages')
            ->fields(array(
              'plurals' => $nplurals,
              'formula' => $plural,
            ))
            ->condition('language', $write_options['langcode'])
            ->execute();
        }
      }
      $parse_state['write_stats']['header'] = TRUE;
    }

    else {
      // Initialize array used to save our data.
      $save = array(
        'context'  => isset($string['msgctxt']) ? $string['msgctxt'] : '',
        'location' => gettextapi_import_shorten_comments(empty($string['#']) ? array() : $string['#']),
        'plid' => 0,
        'plural' => 0,
      );

      if (strpos($string['msgid'], "\0")) {
        // This string has plural versions.
        $english = explode("\0", $string['msgid'], 2);
        $entries = array_keys($string['msgstr']);
        for ($i = 3; $i <= count($entries); $i++) {
          $english[] = $english[1];
        }
        $translation = array_map('gettextapi_import_append_plural', $string['msgstr'], $entries);
        $english = array_map('gettextapi_import_append_plural', $english, $entries);
        foreach ($translation as $key => $trans) {
          if ($key == 0) {
            $save['plid'] = 0;
          }
          $save['source'] = $english[$key];
          $save['translation'] = $trans;
          $save['plural'] = $key;
          $save['plid'] = gettextapi_import_save_string($save, $parse_state, $write_options, $error_callback);
        }
      }
      else {
        // A simple string to import.
        $save['source'] = $string['msgid'];
        $save['translation'] = $string['msgstr'];
        gettextapi_import_save_string($save, $parse_state, $write_options, $error_callback);
      }
    }
  }
}

/**
 * Import one string into the database.
 *
 * @param $save
 *   String data to save.
 * @param $parse_state
 *   Current parser state.
 * @param $write_options
 *   Options for writing the string to the database.
 * @param $error_callback
 *   To invoke if we find an error.
 *
 * @return
 *   The string ID of the existing string modified or the new string added.
 */
function gettextapi_import_save_string($save, &$parse_state, $write_options, $error_callback) {
  $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = :context AND textgroup = :textgroup", array(':source' => $save['source'], ':context' => $save['context'], ':textgroup' => $write_options['textgroup']))->fetchField();

  if (!empty($save['translation'])) {
    // Skip this string unless it passes a check for dangerous code.
    // Text groups other than default still can contain HTML tags
    // (i.e. translatable blocks).
    if ($write_options['textgroup'] == "default" && !locale_string_is_safe($save['translation'])) {
      $parse_state['write_stats']['skipped']++;
      $lid = 0;
      $error_callback('translation contains disallowed HTML tags, skipped for security reasons', $parse_state);
    }
    elseif (!empty($lid)) {
      // We have this source string saved already. Update location information.
      db_update('locales_source')
        ->fields(array(
          'location' => $save['location'],
        ))
        ->condition('lid', $lid)
        ->execute();

      $exists = db_query("SELECT COUNT(lid) FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $write_options['langcode']))->fetchField();

      if (!$exists) {
        // No translation in this language.
        db_insert('locales_target')
          ->fields(array(
            'lid' => $lid,
            'language' => $write_options['langcode'],
            'translation' => $save['translation'],
            'plid' => $save['plid'],
            'plural' => $save['plural'],
          ))
          ->execute();

        $parse_state['write_stats']['added']++;
      }
      elseif ($write_options['mode'] == LOCALE_IMPORT_OVERWRITE) {
        // Translation exists, only overwrite if instructed.
        db_update('locales_target')
          ->fields(array(
            'translation' => $save['translation'],
            'plid' => $save['plid'],
            'plural' => $save['plural'],
          ))
          ->condition('language', $write_options['langcode'])
          ->condition('lid', $lid)
          ->execute();

        $parse_state['write_stats']['updated']++;
      }
    }
    else {
      // No such source string in the database yet.
      $lid = db_insert('locales_source')
        ->fields(array(
          'location' => $save['location'],
          'source' => $save['source'],
          'context' => (string) $save['context'],
          'textgroup' => $write_options['textgroup'],
        ))
        ->execute();

      db_insert('locales_target')
        ->fields(array(
          'lid' => $lid,
          'language' => $write_options['langcode'],
          'translation' => $save['translation'],
          'plid' => $save['plid'],
          'plural' => $save['plural'],
        ))
        ->execute();

      $parse_state['write_stats']['added']++;
    }
  }
  elseif ($write_options['mode'] == LOCALE_IMPORT_OVERWRITE) {
    // Empty translation, remove existing if instructed.
    db_delete('locales_target')
      ->condition('language', $write_options['langcode'])
      ->condition('lid', $lid)
      ->condition('plid', $save['plid'])
      ->condition('plural', $save['plural'])
      ->execute();

    $parse_state['write_stats']['deleted']++;
  }

  return $lid;
}

/**
 * Sets an error message occurred during Gettext .po parsing.
 *
 * @param $message
 *   Explanation of the error.
 * @param $parse_state
 *   State of the read operation such as file and line number.
 */
function gettextapi_import_error_callback($message, $parse_state) {
  $t = get_t();
  drupal_set_message(
    $t(
      'Error: @error in @filename on line @line.',
      array(
        '@filename' => $parse_state['file'],
        // @todo: this still keeps $message untranslated.
        '@error' => $message,
        '@line' => $parse_state['lineno'],
      )
    ),
    'error'
  );
}

// == Parser state machine =====================================================

/**
 * Parse (part of) Gettext Portable Object data and use a writer to store strings.
 *
 * @param $read_callback
 *   Callback that provides lines of a Gettext PO structure for parsing.
 * @param $parse_state
 *   State and options for reading; updated with current state.
 * @param $write_callback
 *   Callback to invoke when a saveable part of the .po stream is identified.
 * @param $write_options
 *   Options to send to the write callback that are results of code outside of
 *   this parser, such as writing mode.
 * @param $error_callback
 *   Callback invoked when an error happens.
 */
function gettextapi_import_read_po($read_callback, &$parse_state, $write_callback, $write_options, $error_callback) {

  /*
   * The parser context. Can be:
   *  - 'COMMENT' (#)
   *  - 'MSGID' (msgid)
   *  - 'MSGID_PLURAL' (msgid_plural)
   *  - 'MSGCTXT' (msgctxt)
   *  - 'MSGSTR' (msgstr or msgstr[])
   *  - 'MSGSTR_ARR' (msgstr_arg)
   */
  $parse_state += array('context' => 'COMMENT');
  $context = &$parse_state['context'];

  // Current entry being read.
  $current = array();

  // Current plurality for 'msgstr[]'.
  $plural = 0;

  while ($line = $read_callback($parse_state, $error_callback)) {

    // Trim away the linefeed.
    $line = trim(strtr($line, array("\\\n" => "")));

    if (!strncmp('#', $line, 1)) {
      // Lines starting with '#' are comments.

      if ($context == 'COMMENT') {
        // Already in comment token, insert the comment.
        $current['#'][] = substr($line, 1);
      }
      elseif (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
        // We are currently in string token, close it out.
        $write_callback($current, $parse_state, $write_options, $error_callback);

        // Start a new entry for the comment.
        $current         = array();
        $current['#'][]  = substr($line, 1);

        $context = 'COMMENT';
      }
      else {
        // A comment following any other token is a syntax error.
        $error_callback('"msgstr" expected but not found', $parse_state);
        return FALSE;
      }
    }
    elseif (!strncmp('msgid_plural', $line, 12)) {
      // A plural form for the current message.

      if ($context != 'MSGID') {
        // A plural form cannot be added to anything else but the id directly.
        $error_callback('"msgid_plural" expected but not found', $parse_state);
        return FALSE;
      }

      // Remove 'msgid_plural' and trim away whitespace.
      $line = trim(substr($line, 12));
      // At this point, $line should now contain only the plural form.

      $quoted = gettextapi_import_parse_quoted($line);
      if ($quoted === FALSE) {
        // The plural form must be wrapped in quotes.
        $error_callback('"msgid_plural" not properly wrapped in quotes', $parse_state);
        return FALSE;
      }

      // Append the plural form to the current entry.
      $current['msgid'] .= "\0" . $quoted;

      $context = 'MSGID_PLURAL';
    }
    elseif (!strncmp('msgid', $line, 5)) {
      // Starting a new message.

      if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
        // We are currently in a message string, close it out.
        $write_callback($current, $parse_state, $write_options, $error_callback);

        // Start a new context for the id.
        $current = array();
      }
      elseif ($context == 'MSGID') {
        // We are currently already in the context, meaning we passed an id with no data.
        $error_callback('unexpected "msgid"', $parse_state);
        return FALSE;
      }

      // Remove 'msgid' and trim away whitespace.
      $line = trim(substr($line, 5));
      // At this point, $line should now contain only the message id.

      $quoted = gettextapi_import_parse_quoted($line);
      if ($quoted === FALSE) {
        // The message id must be wrapped in quotes.
        $error_callback('"msgid" not properly wrapped in quotes', $parse_state);
        return FALSE;
      }

      $current['msgid'] = $quoted;
      $context = 'MSGID';
    }
    elseif (!strncmp('msgctxt', $line, 7)) {
      // Starting a new context.

      if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
        // We are currently in a message, start a new one.
        $write_callback($current, $parse_state, $write_options, $error_callback);
        $current = array();
      }
      elseif (!empty($current['msgctxt'])) {
        // A context cannot apply to another context.
        $error_callback('unexpected "msgctxt"', $parse_state);
        return FALSE;
      }

      // Remove 'msgctxt' and trim away whitespaces.
      $line = trim(substr($line, 7));
      // At this point, $line should now contain the context.

      $quoted = gettextapi_import_parse_quoted($line);
      if ($quoted === FALSE) {
        // The context string must be quoted.
        $error_callback('"msgctxt" not properly wrapped in quotes', $parse_state);
        return FALSE;
      }

      $current['msgctxt'] = $quoted;

      $context = 'MSGCTXT';
    }
    elseif (!strncmp('msgstr[', $line, 7)) {
      // A message string for a specific plurality.

      if (($context != 'MSGID') && ($context != 'MSGCTXT') && ($context != 'MSGID_PLURAL') && ($context != 'MSGSTR_ARR')) {
        // Message strings must come after msgid, msgxtxt, msgid_plural, or other msgstr[] entries.
        $error_callback('unexpected "msgstr[]"', $parse_state);
        return FALSE;
      }

      // Ensure the plurality is terminated.
      if (strpos($line, ']') === FALSE) {
        $error_callback('plural variant not properly specified', $parse_state);
        return FALSE;
      }

      // Extract the plurality.
      $frombracket = strstr($line, '[');
      $plural = substr($frombracket, 1, strpos($frombracket, ']') - 1);

      // Skip to the next whitespace and trim away any further whitespace, bringing $line to the message data.
      $line = trim(strstr($line, " "));

      $quoted = gettextapi_import_parse_quoted($line);
      if ($quoted === FALSE) {
        // The string must be quoted.
        $error_callback('plural variant not properly wrapped in quotes', $parse_state);
        return FALSE;
      }

      $current['msgstr'][$plural] = $quoted;

      $context = 'MSGSTR_ARR';
    }
    elseif (!strncmp("msgstr", $line, 6)) {
      // A string for the an id or context.

      if (($context != 'MSGID') && ($context != 'MSGCTXT')) {
        // Strings are only valid within an id or context scope.
        $error_callback('unexpected "msgstr"', $parse_state);
        return FALSE;
      }

      // Remove 'msgstr' and trim away away whitespaces.
      $line = trim(substr($line, 6));
      // At this point, $line should now contain the message.

      $quoted = gettextapi_import_parse_quoted($line);
      if ($quoted === FALSE) {
        // The string must be quoted.
        $error_callback('"msgstr" not properly wrapped in quotes', $parse_state);
        return FALSE;
      }

      $current['msgstr'] = $quoted;

      $context = 'MSGSTR';
    }
    elseif ($line != '') {
      // Anything that is not a token may be a continuation of a previous token.

      $quoted = gettextapi_import_parse_quoted($line);
      if ($quoted === FALSE) {
        // The string must be quoted.
        $error_callback('unexpected data', $parse_state);
        return FALSE;
      }

      // Append the string to the current context.
      if (($context == 'MSGID') || ($context == 'MSGID_PLURAL')) {
        $current['msgid'] .= $quoted;
      }
      elseif ($context == 'MSGCTXT') {
        $current['msgctxt'] .= $quoted;
      }
      elseif ($context == 'MSGSTR') {
        $current['msgstr'] .= $quoted;
      }
      elseif ($context == 'MSGSTR_ARR') {
        $current['msgstr'][$plural] .= $quoted;
      }
      else {
        // No valid context to append to.
        $error_callback('unexpected string', $parse_state);
        return FALSE;
      }
    }
  }

  // End of PO file, closed out the last entry.
  if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
    $write_callback($current, $parse_state, $write_options, $error_callback);
  }
  elseif ($context != 'COMMENT') {
    $error_callback('data ended unexpectedly', $parse_state);
    return FALSE;
  }

  return TRUE;
}

// == Utility functions for components of .po parsing ==========================

/**
 * Parses a string in quotes.
 *
 * @param $string
 *   A string specified with enclosing quotes.
 * @return
 *   The string parsed from inside the quotes.
 */
function gettextapi_import_parse_quoted($string) {
  if (substr($string, 0, 1) != substr($string, -1, 1)) {
    // Start and end quotes must be the same.
    return FALSE;
  }
  $quote = substr($string, 0, 1);
  $string = substr($string, 1, -1);
  if ($quote == '"') {
    // Double quotes: strip slashes.
    return stripcslashes($string);
  }
  elseif ($quote == "'") {
    // Simple quote: return as-is.
    return $string;
  }
  else {
    // Unrecognized quote.
    return FALSE;
  }
}

/**
 * Parses a Gettext Portable Object file header.
 *
 * @param $header
 *   A string containing the complete header.
 *
 * @return
 *   An associative array of key-value pairs.
 */
function gettextapi_import_parse_header($header) {
  $header_parsed = array();
  $lines = array_map('trim', explode("\n", $header));
  foreach ($lines as $line) {
    if ($line) {
      list($tag, $contents) = explode(":", $line, 2);
      $header_parsed[trim($tag)] = trim($contents);
    }
  }
  return $header_parsed;
}

/**
 * Parses a Plural-Forms entry from a Gettext Portable Object header.
 *
 * @param $pluralforms
 *   A string containing the Plural-Forms entry.
 * @param $parse_state
 *   The current parser state.
 * @param $error_callback
 *   Callback to invoke if we find an error parsing the plural formula.
 *
 * @return
 *   An array containing the number of plurals and a
 *   formula in PHP for computing the plural form.
 */
function gettextapi_import_parse_plural_forms($pluralforms, &$parse_state, $error_callback) {
  // First, delete all whitespace
  $pluralforms = strtr($pluralforms, array(" " => "", "\t" => ""));

  // Select the parts that define nplurals and plural
  $nplurals = strstr($pluralforms, "nplurals=");
  if (strpos($nplurals, ";")) {
    $nplurals = substr($nplurals, 9, strpos($nplurals, ";") - 9);
  }
  else {
    return FALSE;
  }
  $plural = strstr($pluralforms, "plural=");
  if (strpos($plural, ";")) {
    $plural = substr($plural, 7, strpos($plural, ";") - 7);
  }
  else {
    return FALSE;
  }

  // Get PHP version of the plural formula
  $plural = gettextapi_import_parse_arithmetic($plural);

  if ($plural !== FALSE) {
    return array($nplurals, $plural);
  }
  else {
    $error_callback('plural formula could not be parsed', $parse_state);
    return FALSE;
  }
}

/**
 * Parses and sanitizes an arithmetic formula into a PHP expression.
 *
 * While parsing, we ensure, that the operators have the right
 * precedence and associativity.
 *
 * @param $string
 *   A string containing the arithmetic formula.
 *
 * @return
 *   The PHP version of the formula.
 */
function gettextapi_import_parse_arithmetic($string) {
  // Operator precedence table
  $precedence = array("(" => -1, ")" => -1, "?" => 1, ":" => 1, "||" => 3, "&&" => 4, "==" => 5, "!=" => 5, "<" => 6, ">" => 6, "<=" => 6, ">=" => 6, "+" => 7, "-" => 7, "*" => 8, "/" => 8, "%" => 8);
  // Right associativity
  $right_associativity = array("?" => 1, ":" => 1);

  $tokens = gettextapi_import_tokenize_formula($string);

  // Parse by converting into infix notation then back into postfix
  // Operator stack - holds math operators and symbols
  $operator_stack = array();
  // Element Stack - holds data to be operated on
  $element_stack = array();

  foreach ($tokens as $token) {
    $current_token = $token;

    // Numbers and the $n variable are simply pushed into $element_stack
    if (is_numeric($token)) {
      $element_stack[] = $current_token;
    }
    elseif ($current_token == "n") {
      $element_stack[] = '$n';
    }
    elseif ($current_token == "(") {
      $operator_stack[] = $current_token;
    }
    elseif ($current_token == ")") {
      $topop = array_pop($operator_stack);
      while (isset($topop) && ($topop != "(")) {
        $element_stack[] = $topop;
        $topop = array_pop($operator_stack);
      }
    }
    elseif (!empty($precedence[$current_token])) {
      // If it's an operator, then pop from $operator_stack into $element_stack until the
      // precedence in $operator_stack is less than current, then push into $operator_stack
      $topop = array_pop($operator_stack);
      while (isset($topop) && ($precedence[$topop] >= $precedence[$current_token]) && !(($precedence[$topop] == $precedence[$current_token]) && !empty($right_associativity[$topop]) && !empty($right_associativity[$current_token]))) {
        $element_stack[] = $topop;
        $topop = array_pop($operator_stack);
      }
      if ($topop) {
        $operator_stack[] = $topop;   // Return element to top
      }
      $operator_stack[] = $current_token;      // Parentheses are not needed
    }
    else {
      return FALSE;
    }
  }

  // Flush operator stack
  $topop = array_pop($operator_stack);
  while ($topop != NULL) {
    $element_stack[] = $topop;
    $topop = array_pop($operator_stack);
  }

  // Now extract formula from stack
  $previous_size = count($element_stack) + 1;
  while (count($element_stack) < $previous_size) {
    $previous_size = count($element_stack);
    for ($i = 2; $i < count($element_stack); $i++) {
      $op = $element_stack[$i];
      if (!empty($precedence[$op])) {
        $f = "";
        if ($op == ":") {
          $f = $element_stack[$i - 2] . "):" . $element_stack[$i - 1] . ")";
        }
        elseif ($op == "?") {
          $f = "(" . $element_stack[$i - 2] . "?(" . $element_stack[$i - 1];
        }
        else {
          $f = "(" . $element_stack[$i - 2] . $op . $element_stack[$i - 1] . ")";
        }
        array_splice($element_stack, $i - 2, 3, $f);
        break;
      }
    }
  }

  // If only one element is left, the number of operators is appropriate
  if (count($element_stack) == 1) {
    return $element_stack[0];
  }
  else {
    return FALSE;
  }
}

/**
 * Backward compatible implementation of token_get_all() for formula parsing.
 *
 * @param $string
 *   A string containing the arithmetic formula.
 *
 * @return
 *   The PHP version of the formula.
 */
function gettextapi_import_tokenize_formula($formula) {
  $formula = str_replace(" ", "", $formula);
  $tokens = array();
  for ($i = 0; $i < strlen($formula); $i++) {
    if (is_numeric($formula[$i])) {
      $num = $formula[$i];
      $j = $i + 1;
      while ($j < strlen($formula) && is_numeric($formula[$j])) {
        $num .= $formula[$j];
        $j++;
      }
      $i = $j - 1;
      $tokens[] = $num;
    }
    elseif ($pos = strpos(" =<>!&|", $formula[$i])) { // We won't have a space
      $next = $formula[$i + 1];
      switch ($pos) {
        case 1:
        case 2:
        case 3:
        case 4:
          if ($next == '=') {
            $tokens[] = $formula[$i] . '=';
            $i++;
          }
          else {
            $tokens[] = $formula[$i];
          }
          break;
        case 5:
          if ($next == '&') {
            $tokens[] = '&&';
            $i++;
          }
          else {
            $tokens[] = $formula[$i];
          }
          break;
        case 6:
          if ($next == '|') {
            $tokens[] = '||';
            $i++;
          }
          else {
            $tokens[] = $formula[$i];
          }
          break;
      }
    }
    else {
      $tokens[] = $formula[$i];
    }
  }
  return $tokens;
}

/**
 * Modify a string to contain proper count indices.
 *
 * This is a callback function used via array_map().
 *
 * @param $entry
 *   An array element.
 * @param $key
 *   Index of the array element.
 */
function gettextapi_import_append_plural($entry, $key) {
  // No modifications for 0, 1
  if ($key == 0 || $key == 1) {
    return $entry;
  }

  // First remove any possibly false indices, then add new ones
  $entry = preg_replace('/(@count)\[[0-9]\]/', '\\1', $entry);
  return preg_replace('/(@count)/', "\\1[$key]", $entry);
}

/**
 * Generate a short, one string version of the passed comment array.
 *
 * @param $comment
 *   An array of strings containing a comment.
 *
 * @return
 *   Short one string version of the comment.
 */
function gettextapi_import_shorten_comments($comment) {
  $comm = '';
  while (count($comment)) {
    $test = $comm . substr(array_shift($comment), 1) . ', ';
    if (strlen($comm) < 130) {
      $comm = $test;
    }
    else {
      break;
    }
  }
  return trim(substr($comm, 0, -2));
}
